von Gunnar Hilling
Bevor Sie jetzt die Hände über dem Kopf zusammenschlagen: Es geht nicht um das ungeliebte Annotation Processing Tool (apt), das mit Java 5 eingeführt und mit Java 8 endgültig wieder entsorgt wurde, sondern um das Annotation Processing API. Diese mit Java 6 eingeführte Schnittstelle ermöglicht, den eigentlichen Compilevorgang zu beeinflussen und dabei optional auch neuen Code zu erzeugen.
Drum prüfe …
Der erste Schritt ist, Annotations auf die korrekte Verwendung zu prüfen: Bei der Definition einer Annotation ist es notwendig, die Codeelemente anzugeben, die annotiert werden dürfen. Zum Beispiel soll die Annotation GenerateModel dazu dienen, später aus einer Klasse heraus ein statisches Metamodell zu erzeugen. Deshalb haben wir den Zieltyp TYPE gewählt. Dieser verbietet aber nicht, die Annotation fehlerhaft auf andere Annotations anzuwenden.
@Target(ElementType.TYPE) public @interface GenerateModel { }
… wer Java Annotations bindet
Dieses Problem dürfte bekannt sein. Besonders, wenn eine Annotation im Rahmen einer Bibliothek per Reflection zur Laufzeit verwendet wird, ist es wichtig sicherzustellen, dass sie auch korrekt verwendet wurde. Ansonsten drohen Laufzeitfehler oder die Annotations werden schlimmstenfalls ignoriert. Für genau diesen Anwendungsfall ist die Annotation-Processing-Bibliothek maßgeschneidert. Listing 1 zeigt dazu einen Processor, der die oben definierte Annotation verarbeitet, aber noch keine Prüfungen ausführt. Durch die Annotation SupportedAnnotationTypes weiß der Compiler, für welche Annotations der Processor aufgerufen werden soll. Die eigentliche Arbeit findet dann in der Methode process statt. Über den Rückgabewert false zeigt sie an, dass die Annotation nicht exklusiv durch diesen Processor verarbeitet werden soll.
Listing 1: Erste Fassung des „MetamodelVerifier“
@SupportedAnnotationTypes({"de.hilling.lang.metamodel.GenerateModel"}) public class MetamodelVerifier extends AbstractProcessor { @Override public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) { return false; } }
Der Quellcode des endgültigen Projekts liegt bei GitHub und darf gerne als Vorlage für eigene Projekte verwendet werden [1]. Zum Bauen braucht es lediglich ein JDK und Maven. Das Projekt enthält neben dem Maven-Aggregator ein Schnittstellenprojekt (API), den eigentlichen Processor und ein separates Integration-tests-Projekt. Der Processor wird nur von Projekten benötigt, die auch wirklich Code erzeugen.
Gewöhnungssache
Jetzt könnte der geneigte Entwickler meinen, dass er nur noch per Reflection die leere Methode mit Leben füllen muss. Ganz so einfach ist es nicht. Wir befinden uns ja noch in der Ausführung des Compilers und damit muss nicht jeder Typ bereits als übersetzte Java-Klasse vorliegen. Daher wurde das Package java.lang.model entwickelt. Es dient dazu, noch nicht übersetzten Code darzustellen. Zu java.lang.model gehören die Schnittstellen TypeMirror und Element. TypeMirror bildet einen deklarierten Typ ab, Element einen Teil des Programms, etwa eine Klasse, eine Methode oder ein Package. Beide bilden neben den Klassen auch Elemente ab, die über Reflection grundsätzlich nicht zugänglich sind –wie Javadocs oder Type-Parameter – und ansonsten der Runtime Type Erasure zum Opfer fallen würden.
Zur Prüfung liefert das RoundEnvironment alle Elemente, die mit GenerateModel annotiert sind. Diese werden anschließend in verifyNotAnAnnotation() geprüft. Die Prüfung ist in diesem Fall einfach, da lediglich ausgeschlossen werden muss, dass es sich beim annotierten Element selbst um eine Annotation handelt.
Listing 2: Prüfung der Annotation
@Override public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) { roundEnv.getElementsAnnotatedWith(GenerateModel.class) .forEach(this::verifyNotAnAnnotation); return false; } private void verifyNotAnAnnotation(Element element) { if (element.getKind() == ElementKind.ANNOTATION_TYPE) { compilerErrorMessage(element); } }
getKind() liefert hierbei die Art des Sourcecode-Elements. In der Methode compilerErrorMessage() wird schließlich der Fehler ausgegeben:
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, ERROR_MESSAGE, element, annotation);
Durch die Ausgabe der Nachricht mit Diagnostic.Kind.Error ist der Compile-Vorgang automatisch fehlgeschlagen. Eine Exception darf der Prozessor nicht auswerfen, schließlich sollen auch bei einem fehlgeschlagenen Compilerlauf so viele Fehler wie möglich erfasst werden können. Durch die Argumente element und annotation kann der Compiler eine präzise Rückmeldung über den Fehler liefern.
Test, Test …
Spätestens an dieser Stelle sollte sich die Frage stellen, wie man einen solchen Processor testen kann, denn es gehört offensichtlich zu den zu testenden Fällen, dass es zu einem Compilefehler kommt. Dieser Fall wird durch den CI-Prozess üblicherweise als Fehler angesehen. Zum Glück gibt es hierzu eine Bibliothek, die das Testen erleichtert, nämlich Googles Compile Testing [2]. Innerhalb eines normalen JUnit-Tests setzt man zunächst einen Java-Compiler mit den gewünschten Processors auf:
@Before public void setUpCompiler() { compiler = javac().withProcessors(new MetamodelVerifier(),new MetamodelGenerator()); }
Anschließend kann mit diesem Compiler Code übersetzt werden, der nicht im direkten Source-Pfad liegt oder sonst ohne Prozessor übersetzt wird. Daher wird der eigentliche Compilevorgang während der CI-Läufe nicht auf einen Fehler stoßen. Nach der Übersetzung kann auf Fehler geprüft und der generierte Code mit Referenzklassen strukturell verglichen werden (Listing 3).
Listing 3: Compile Testing
@Test public void failOnIllegalAnnotation() { final JavaFileObject illegalSource = source(IllegallyUsedAnnotation.class); Compilation compilation = compiler.compile(illegalSource); assertAbout(compilations()).that(compilation) .hadErrorContaining(MetamodelVerifier.ERROR_MESSAGE) .inFile(illegalSource) .onLine(3) .atColumn(1); assertThat(compilation.status()).isEqualTo(Compilation.Status.FAILURE); } private JavaFileObject source(Class<?> clazz) { return JavaFileObjects.forResource(clazz.getCanonicalName() .replace('.', '/') + ".java"); }
Die Assertions stammen hier aus der von Compile Testing benötigten Bibliothek google.commons.truth. Sie eignen sich gerade, um Compilefehler präzise und gut verständlich auszudrücken. Jetzt fehlt nur noch die eigentliche Arbeit: das Erzeugen der Metamodel-Klassen.
… bis alles übersetzt werden kann
Da mehrere Processors kombiniert werden können, ist es möglich, dass ein Processor die von einem anderen generierten Quellen verarbeiten muss. Außerdem soll es möglich sein, im Quellcode Klassen zu verwenden, die aus diesem erst noch generiert werden müssen. Um das umzusetzen, führt der Compiler mehrere Durchläufe über die Quellen aus. In jedem Durchlauf werden jeweils alle Annotation Processors aufgerufen, bis kein neuer von einem Processor zu verarbeitender Code mehr erzeugt wurde. Anschließend findet eine finale Runde statt, die dadurch angezeigt wird, dass RoundEnvironment.processingOver() true liefert.
Damit dies funktioniert, sollte ein Processor, wenn seine Ausgabe von anderen Processors weiterverarbeitet werden können soll, in jeder Runde alles erzeugen, was generiert werden kann. Fehlen noch Quellen, so sollte das ignoriert werden, bis die letzte Runde eingeläutet wird. Erst jetzt sollten Fehler aufgrund fehlender Quellen erzeugt werden. Für „Spielprojekte“ kann diese Anforderung natürlich ignoriert werden, aber um einen Processor Production Ready zu machen, sollte dieser auf jeden Fall auf Kompatibilität mit anderen Processors und Generierung in mehreren Runden getestet werden. Im Beispielprojekt wird kein annotierter Code erzeugt, der als Input für andere Generatoren dienen könnte, daher entfallen diese Tests hier.
Modellbau
Zur Vereinfachung wird aus den Quellen zunächst ein Modell für die Attribute erzeugt. Die Klasse ClassModel enthält hierzu eine Map, die jedem Attributnamen eine Beschreibung zuordnet. Außerdem werden die Namen aller Attribute in der korrekten Reihenfolge in einer Liste gehalten. Dies ist eine sinnvolle Information im generierten Metamodel, da die Reihenfolge der per Reflection abgefragten Methoden und Variablen nicht mit der im Quellcode übereinstimmen muss. Das ist häufig ärgerlich, zum Beispiel, wenn aus einer Klasse eine Oberfläche generiert werden soll und dann zusätzliche Annotations notwendig werden, nur um die Reihenfolge der Variablen oder Methoden nachvollziehen zu können.
Für jeden gefundenen annotierten Typ wird aus dem Processor heraus eine neue ClassHandler-Instanz aufgerufen:
private void generateMetamodel(Element element) { TypeElement typeElement = (TypeElement) element; final ClassModel classModel = new ClassHandler(typeElement, processingEnv).invoke(); writeMetaClass(typeElement, classModel); }
Der ClassHandler erstellt ein ClassModel, aus dem anschließend die Metaklasse generiert wird. Die Implementierung von ClassHandler (Listing 4) verwendet dabei das Stream API, um aus allen Kindelementen der Klasse zunächst die Methoden zu filtern. Anschließend wird für jede Methode geprüft, ob es sich um einen Getter oder Setter handelt und das ClassModel entsprechend angepasst.
Listing 4: “ClassHandler”
private final TypeElement type; private final ClassModel classModel; ClassHandler(TypeElement element, ProcessingEnvironment processingEnvironment) { this.classModel = new ClassModel(processingEnvironment); this.type = element; } ClassModel invoke() { type.getEnclosedElements().stream() .filter(element -> element.getKind() == ElementKind.METHOD) .map(element -> (ExecutableElement) element) .forEach(this::collectAccessorInfo); return classModel; } private void collectAccessorInfo(ExecutableElement methodRef) { if (Utils.isGetter(methodRef)) { String attributeName = Utils.attributeNameForAccessor(methodRef); AttributeInfo info = classModel.getInfo(attributeName); info.setType(methodRef.getReturnType()); } else if (Utils.isSetter(methodRef)) { String attributeName = Utils.attributeNameForAccessor(methodRef); classModel.getInfo(attributeName).setWritable(true); } }
Nun ist die schwerste Arbeit geschafft und man kann sich zurücklehnen, um möglichst entspannt darüber zu sinnieren, wie man jetzt am einfachsten aus den verfügbaren Informationen den gewünschten Code erzeugt. Ein naheliegender Ansatz besteht natürlich darin, straight forward mithilfe von StringBuilder und vielleicht einer Template-Engine dem Problem zu Leibe zu rücken. Daraus ergeben sich jedoch einige Nachteile. Es ist schwer zu gewährleisten, dass der erzeugte Code syntaktisch und semantisch korrekt ist. Noch schwerer ist es, Code zu erzeugen, der den üblichen Gepflogenheiten entspricht und damit lesbar bleibt. Lesbarer Code sollte ein wichtiges Ziel bei der Generierung sein. Zum einen ist es während der Entwicklung einfacher, eventuelle Fehler zu erkennen, zum anderen ist es für die Anwender des Werkzeugs beim Debugging natürlich viel angenehmer, lesbaren Quellcode vorgesetzt zu bekommen [3].
Poesie
Glücklicherweise gibt es auch hierzu inzwischen eine mächtige und dennoch einfach zu verwendende Bibliothek [4]. JavaPoet erzeugt flexibel lesbaren Code und kann zudem die Model-Klassen aus java.lang.model verarbeiten. Außerdem kümmert es sich um die korrekte Erstellung von Importanweisungen und Einrückungen. Die Elemente einer Klasse können über einen Builder erstellt werden. Eine hello, world!-Klasse wird durch den Code in Listing 5 erzeugt.
Listing 5: Hello, World!
TypeSpec.Builder typeBuilder = TypeSpec.classBuilder(CLASS_NAME) .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT); MethodSpec.Builder main = MethodSpec.methodBuilder("main") .addParameter(String[].class, "args") .addModifiers(Modifier.PUBLIC, Modifier.STATIC) .addStatement("$T.out.println($S)", System.class, "hello, world!") .returns(void.class); typeBuilder.addMethod(main.build()); JavaFile.builder(PACKAGE_NAME, typeBuilder.build()) .indent(" ") .build() .writeTo(System.out);
Lesen Sie auch: Java 9: neuer, toller, bunter?
JavaPoet zeichnet sich einerseits durch einfache Benutzbarkeit aus, da es nicht direkt einen Syntaxbaum abbildet, sondern die Möglichkeit bietet, Code auch frei zu erzeugen. Andererseits gibt es mit den Buildern genug Möglichkeiten, die Grobstruktur des Codes auszudrücken. Speziell die Erzeugung von Statements wird durch die bibliothekseigenen Formatierer sehr vereinfacht. Die Zeichenkette $T.out.println($S) erwartet für die Expansionen einen Typ und eine Zeichenkette. Für den übergebenen Typ werden automatisch Imports erzeugt und der Typ wie gewünscht im Quellcode formatiert: System.class.println(“hello, world”). Neben den beiden genannten sind die wichtigsten Umwandlungen $L für Literale ohne Escaping wie bei Strings und das $N für die Verwendung von Variablen für Bezeichner im Quelltext.
Für die genaue Verwendung von JavaPoet im Beispielprojekt möchte ich auf die Projektquellen bei GitHub verweisen. Die Generierung lässt sich natürlich wieder hervorragend einfach mit Compile Testing prüfen. Da das Projekt relativ einfach ist, habe ich die Tests in MetamodelGeneratorTest als Integrationstests für den gesamten Generator ausgeführt. Bei komplexeren Projekten ist es natürlich sinnvoll, zum Beispiel nur die Codegenerierung aus einem gegebenen festen Model zu testen. Ich habe mich im Beispiel dazu entschlossen, den zu erwartenden Code direkt im Testpfad des Maven-Projektes abzulegen. Dies erleichtert Anpassungen in der IDE für die Tests.
Der Core Java & JVM Languages Track auf der JAX 2020
Paket für Sie!
Wie ist ein solcher Processor in ein eigenes Projekt einzubinden? Dies funktioniert über die ServiceLoader-Funktionalität von Java. Hierzu wird eine Datei javax.annotation.processing.Processor unter META-INF/services im processor.jar gespeichert:
de.hilling.lang.metamodel.MetamodelVerifier de.hilling.lang.metamodel.MetamodelGenerator
Der Compiler erkennt die Ressource und aktiviert die angegebenen Processors. Daher ist es auch sinnvoll, das API separat verwenden zu können. Es kann dann als transitive Abhängigkeit deklariert werden, während der Processor ja lediglich für die Generierung benötigt wird. Durch das separate integration-tests-Projekt kann auch das Packaging mit getestet werden. Nebenbei dient es natürlich auch als Dokumentation mit Beispielen zur Verwendung.
Wie weiter?
Ich hoffe, ich konnte zeigen, dass man mit vertretbarem Aufwand und ohne Teilnahme an einer Compilerbauvorlesung nützliche Compilererweiterungen mit dem Annotation Processor realisieren kann. Außerdem ist es inzwischen einfach möglich, diese sinnvoll zu testen, was die Entwicklung sehr erleichtert. Ebenso ist die Codegenerierung mit den dargestellten Methoden effizient realisierbar und erzeugt gut lesbaren Code.
Neben der Erstellung eigener Prozessoren finden sich im Netz auch viele fertige Projekte, die den Annotation Processor nutzen. Hierzu möchte ich speziell das Immutables-Projekt [5] empfehlen, das es ermöglicht, einfach verwendbare Immutable Value Objects automatisch zu generieren. Um den Horizont etwas zu erweitern, bietet sich zum Beispiel ein Blick auf Derive4J [6] an, das es unter anderem erlaubt, die Erstellung von DSLs in Java deutlich zu vereinfachen.
Links & Literatur
[1] Quellcode des MetamodelVerifier: https://github.com/guhilling/java-metamodel-generator.git
[2] Compile Tester von Google: https://github.com/google/compile-testing/
[3] Unterhaltsame Beispiele, wie man es nicht machen sollte: https://www.ioccc.org
[4] JavaPoet: https://github.com/square/javapoet/
[5] Immutables-Projekt: http://immutables.github.io
[6] Derive4J: https://github.com/derive4j/derive4j/
Erfahren Sie mehr über Core Java & JVM Languages auf der JAX 2020:
● AdoptOpenJDK – Was ist das eigentlich?
● JVM fine tuning in a Docker Container